今天這個事故的主角,是一個橫跨資料庫兩端、殊途同歸的 bug:兩種完全不同的成因,撞出同一種災難。 而其中一條路徑,是三天前 Day 02 講過的那個「條件被省略」的老問題,換了個馬甲又發作了一次。
某天看著 docker logs -f 的輸出,畫面停在一行 Node 的錯誤堆疊後就斷了——容器整個崩潰重啟了。翻開 log,最後幾行是:
node:internal/process/promises:394
triggerUncaughtException(err, true /* fromPromise */);
這是沒有被捕捉的 Promise rejection。Node 15 之後,預設行為是把沒有 .catch() 或 try/catch 接住的非同步錯誤直接升級成致命例外,整個 process 應聲而倒。
但這只是「怎麼死的」,不是「為什麼死」。往上翻,真正的錯誤是一句逾時訊息:「查詢下載紀錄逾時(20000ms)」。而這句逾時,背後藏著一個已經追了好幾天、跨了好幾個工作階段的老案子。
回頭翻舊紀錄,第一次爆發是在幾天前。維護廠商的監控報表持續跳出 ORA-01555(snapshot too old)告警,他們的判讀是「有長時間鎖定」,並建議「請 AP 團隊做 SQL Tuning」。
這個判讀方向其實搞反了——ORA-01555 是讀取端的錯誤,跟被鎖住沒有因果關係,是某個查詢跑太久,undo 空間被別人的交易覆蓋掉了。但廠商附上的那段 SQL 語法本身,比他們的判讀更值得細看:一句對 tableA 整張表沒有任何 WHERE 條件的 SELECT ... FOR UPDATE。
這代表它不是在讀資料,是在對全表逐筆加鎖,而且一次跑了 2363 秒(約 39 分鐘)都沒跑完。動態效能檢視表查出來的數字更驚人:這道語句在 72 小時內執行了 2005 次,平均每次 21.8 分鐘——換算下來,累計耗費的時間超過 30 天,全部塞進 3 天之內。
追下去的過程,AI 一路被證據修正方向:一度懷疑是連線池 min=10 造成的,被指出「pool 只是放大器,不是起因」;一度以為索引沒建好,實測後發現「下推其實是正常的,索引也生效了」。真正的因果鏈,是在追出「Sequelize 在 where: { columnA: guid } 的 guid 為 undefined 時,會把整個條件丟掉」之後才拼上的:
undefined,Sequelize 組字串時把 WHERE 整段省略UPDATE
oracle_fdw 忠實地把它翻譯成對 Oracle 的無條件 SELECT ... FOR UPDATE
ORA-01555
這正是 Day 02 那個「= NULL 被忽略」的老問題——同一種歷史包袱,只是這次不是應用邏輯錯誤,是整張表被鎖死、拖垮所有相關作業。當時的處置是建索引:把原本沒有 WHERE 就要掃全表的執行計畫,換成能命中索引的路徑。效果立竿見影——同一句語法,有沒有走到索引,執行時間從 1902 秒降到 1.19 秒,快了約 1600 倍。
但建索引解決的是「跑起來多慢」,沒有堵住「條件為什麼會消失」這個根因。
第一次事件當時被我標成已結案。幾天後,容器崩潰的訊息又出現了,我判斷這可能是同一個問題的延續,就先開了一個新的工作階段,把當下觀察到的狀況記錄成筆記,再切回舊的那個工作階段去繼續深查——舊的那條線索已經有完整脈絡,接著查比從頭開始有效率。
這次的日誌顯示,送到 PostgreSQL 的語句是:
UPDATE "tableA" SET ... WHERE "columnA" = $5 -- $5 = ''
WHERE 子句這次沒有不見。 空字串跟 undefined 在 Sequelize 裡的行為不一樣——'' 是一個合法的值,會正常組進 WHERE,只有 undefined 才會被整個丟掉。所以第一次事件裝的那道防線(攔 undefined)對這次完全沒用,是另一條路徑殊途同歸到同一種災難。
在新開的那個工作階段裡,我丟出一個憑印象的猜測:
「印象中 PostgreSQL 找不到資料時反而回應更慢,會不會他就是全表掃描呢?」
AI 當場給了一個解釋:Oracle 的 B-tree 索引不儲存 NULL 值,空字串又等同 NULL,所以這個條件用不上索引,只能全表掃描。這個解釋聽起來完整,但其實還沒有被驗證過,只是憑一般 Oracle 知識給出的合理推測。
真正驗證出精確機制的,是切回舊的工作階段之後——因為那裡已經有前一次事件累積的完整脈絡(表結構、Sequelize 行為、oracle_fdw 設定都已經在對話裡),AI 這次給出的答案精確得多:真正的機制不是索引存不存 NULL,是 oracle_fdw 的下推原則——「語意不能保證等價,就不下推」。 Oracle 裡的空字串等同 NULL,PostgreSQL 裡的空字串是一個實際的值,兩邊語意不相容,oracle_fdw 判斷這個條件沒辦法安全地送給 Oracle,於是選擇把整張表拉回 PostgreSQL 本地端再過濾——而拉回本地端這個動作本身,對 Oracle 來說就是一句沒有 WHERE 的 SELECT ... FOR UPDATE。
同一個問題、同一個我,卻在兩個工作階段裡得到精確度不同的答案,原因很單純:舊的工作階段裡有更完整的脈絡可以參照,新的工作階段等於是從零開始猜。 這跟這系列一直在講的「脈絡要餵齊全,AI 才給得準」是同一件事,只是這次不是我漏餵,是兩個工作階段本來就沒有共享記憶,脈絡厚度不一樣,給出來的判斷精確度就不一樣。
驗證證據很乾淨:EXPLAIN VERBOSE 印出的執行計畫裡,有一行 Filter: ((tableA.columnA)::text = '') 出現在 Foreign Scan 節點上——代表這個條件是在本地端過濾的,沒有出現在送往 Oracle 的查詢裡。相較之下,先前正常運作的案例(條件是一個實際字串常數)從來不會出現這行 Filter:,因為條件當時就已經被塞進送往 Oracle 的 WHERE 裡處理掉了。
還有一個很扎實的鑑識推理,是在修正計畫裡補上的一句話:
「Oracle 根本無法儲存空字串(存進去就變 NULL),所以那個
''一定是 AP 端自己生出來的,不是從資料庫讀回來的。」
這句話直接把搜尋範圍鎖定在應用程式碼裡,不用去資料庫端大海撈針。
把整件事拆開看,這次的分工很清楚。
AI 負責的是系統性的排查方法:用動態效能檢視表交叉比對鎖定源頭、用 EXPLAIN VERBOSE 逐層驗證下推有沒有生效。這些都是不需要復現、純靠證據推理就能縮小範圍的技巧。
人負責的是業務記憶與現場直覺:是我想起「Sequelize 4.x 時代,條件是 undefined 會被省略」這個歷史包袱,才讓「手動測試怎麼樣都重現不了」這件事有了解釋——因為手動測試永遠是自己刻意組出合法的 SQL,不會複製出 Sequelize 組字串時的省略行為。也是我丟出「查不到資料反而更慢」這個不算精確、但方向正確的直覺,把追查方向從「連線池」拉回到「全表掃描」。
修正計畫裡也把這個教訓寫得很白:新的防護要同時擋住兩條路徑(undefined 和空字串),而且要在說明裡特別標註「null 不要一起擋」,因為亂槍打鳥地把所有空值都攔下來,反而會破壞掉某些合法的「查詢條件本來就是要找 NULL」的用法。防禦性寫法一旦想省事,很容易從「防住一個坑」變成「多挖一個新坑」。
明天要講的是同一批對話裡,另一個更曲折的案子——同一張申報附件表,兩支完全獨立的程式各自成功下載了同一份檔案,各自寫入資料庫,卻 100% 撞號失敗。這次 AI 一連猜錯了七次,每一次都被一個業務規則或部署事實戳破,最後抓到真凶的人,是我自己。